iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Vibe Coding

夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略系列 第 26

Day26|大家都有空,但到底哪個時段最好?從候選時段演算法看懂時間怎麼切、算再合回

  • 分享至 

  • xImage
  •  

昨天介紹完兩個可愛的鎖,今天要換另一個曾經讓我腦袋大打結的地方了(笑)。

先想像一下:

今天你是活動建立者,想辦一場活動,時間可以落在:

09:00~18:00

但你還不知道大家到底哪一段最有空。

你真正想知道的其實不是:

「哪一個人選了哪一格?」

而是:

「在這整個時間範圍裡,哪一段連續時間最多人都有空?」

BuJo 有一種活動情境,就是為了解決這件事。

參與者不用從建立者列好的選項裡投票,而是直接回報:

「我這一整段時間都有空。」

例如:

Alice:18:00 ───────── 20:00
Bob:         19:00 ───────── 21:00

人類一眼就看得出來:

19:00~20:00
→ Alice、Bob 都有空

但程式要怎麼從大家各自填的 Range(時間範圍)裡,整理出這種結果?

BuJo 的做法,是先把整個時間範圍切成固定的小格,一格一格算有多少人有空,再把結果整理成建立者真正可以拿來決定活動時間的候選區間。

今天就來拆這套候選時段重疊計算演算法。


先把大家的時間攤開來看

先從最簡單的例子開始。

假設今天可以選的時間範圍是:

18:00~21:00

Alice 和 Bob 分別回報:

Alice:18:00~20:00
Bob:  19:00~21:00

BuJo 的 computeRangeRanking() 不是直接拿兩條 Range 去猜「最佳時間」。

它會先把整個基準範圍切成固定的 60 分鐘小格:

18:00~19:00
19:00~20:00
20:00~21:00

接著一格一格問:

「這一格,和哪些人的可用時間有重疊?」

所以最後會得到:

時段 有空的人 count
18:00~19:00 Alice 1
19:00~20:00 Alice、Bob 2
20:00~21:00 Bob 1

這樣原本兩條長短不一的 Range,就被整理成了一組可以直接比較的小格子。

最後再依 count 排序:

19:00~20:00 → 2 人
18:00~19:00 → 1 人
20:00~21:00 → 1 人

這樣建立者就能先看出:

整個可選範圍裡,哪些小格有最多人有空。


第一步:把整段時間切成 60 分鐘小格

直接進 computeRangeRanking() 看最清楚。

先只看「切格」這一段:

const segments = [];
let segStart = new Date(windowStart);

while (segStart < windowEnd) {
  const segEnd = new Date(
    Math.min(
      // 每次往後切 60 分鐘
      segStart.getTime() + 60 * 60 * 1000,

      // 最後一格不能超過整個可選範圍
      windowEnd.getTime(),
    ),
  );

  segments.push({
    slot_start: segStart,
    slot_end: segEnd,
  });

  segStart = segEnd;
}

假設:

windowStart = 18:00
windowEnd   = 21:00

跑完之後,就會得到:

18:00~19:00
19:00~20:00
20:00~21:00

這裡採用的是一個很務實的做法:

先把連續時間標準化成固定大小的小格,再逐格計算。

因為每個人回報的 Range 都可能長得不一樣。

有人可能填:

18:00~20:00

另一個人可能填:

19:00~21:00

如果先切成同一套基準,每一格就都能用相同規則判斷。


接著算出每一格有哪些人有空

格子切好之後,下一步就是計算每一格的 Supporters(支持者)。

BuJo 的 Code 是這樣:

const counted = segments.map((seg) => {
  // 找出哪些人的可用 Range 和這一格有重疊
  const covering = ranges.filter(
    (r) =>
      r.start < seg.slot_end &&
      r.end > seg.slot_start,
  );

  // 同一個人即使有多筆 Range 涵蓋這一格
  // 也只能算一個人
  const supporterIds = new Set(
    covering.map((r) => r.user_id),
  );

  return {
    ...seg,
    count: supporterIds.size,
    supporterIds,
  };
});

這裡有兩個很重要的東西:

count
→ 這一格有幾個人支持

supporterIds
→ 到底是哪幾個人支持

這樣建立者最後看到的不只有「幾個人有空」,也能知道這段時間實際是哪些人可以參加。

supporterIds 後面還有另一個用途:

判斷相鄰的時間到底能不能安全合併。


60 分鐘只是計算單位,不是最後答案

60 分鐘只是 BuJo 拿來計算的單位,不代表建立者最後只能選 1 小時。

如果 Alice 從 12:00~15:00 都有空:

12:00~13:00 → Alice
13:00~14:00 → Alice
14:00~15:00 → Alice

對建立者來說,更合理的結果其實是:

12:00~15:00 → Alice

所以切格之後,BuJo 還要把可以安全合併的相鄰區段重新接回去。

但這裡有一個小陷阱:

09:00~10:00 → Alice
10:00~11:00 → Bob

雖然兩格的 count 都是 1,也不能合併。

因為:

人數一樣,不代表是同一群人。

所以 BuJo 合併時除了看時間相鄰、count 相同,還會再確認 supporterIds 完全相同。


要合在一起,三個條件都要成立

BuJo 把這個判斷放在 mergeAdjacentSameCount()

function mergeAdjacentSameCount(countedSegments) {
  const merged = [];

  for (const seg of countedSegments) {
    // 沒有人有空的格子不用顯示
    if (seg.count === 0) continue;

    const last = merged[merged.length - 1];

    if (
      last &&

      // ① 支持人數相同
      last.count === seg.count &&

      // ② 時間真的相鄰
      last.slot_end.getTime() ===
        seg.slot_start.getTime() &&

      // ③ 支持者也必須是同一群人
      sameSupporterSet(
        last.supporterIds,
        seg.supporterIds,
      )
    ) {
      // 三個條件都成立,才把時間往後延長
      last.slot_end = seg.slot_end;
    } else {
      merged.push({ ...seg });
    }
  }

  return merged;
}

所以真正可以合併的條件是:

count 相同
+
時間相鄰
+
supporterIds 完全相同
↓
才可以合併

例如:

09:00~10:00
count = 1
supporters = Alice

10:00~11:00
count = 1
supporters = Alice

這時就可以安全地合併成:

09:00~11:00
count = 1
supporters = Alice

但如果支持者不同,就算 count 一模一樣,也必須保持兩筆。


sameSupporterSet():比的不只是人數

前面知道了,光看 count 還不夠。

例如:

09:00~10:00
[Alice, Bob]

10:00~11:00
[Alice, Carol]

兩個時段明明都是:

count = 2

但有空的人並不是同一群。

所以 BuJo 還會用 sameSupporterSet() 檢查:

function sameSupporterSet(a, b) {
  // 人數不同,一定不是同一群人
  if (a.size !== b.size) return false;

  // 每一個人都要同時存在於另一組
  for (const id of a) {
    if (!b.has(id)) return false;
  }

  return true;
}

它做的事情可以直接理解成:

不只要確認「都是 2 個人」,還要確認「是不是同樣這 2 個人」。

只有支持者完全相同,相鄰的兩段時間才可以繼續合併。


從 Range 到候選區間,完整走一次

一路拆到這裡,computeRangeRanking() 做的事情其實可以整理成:

大家回報各自的可用 Range
↓
把整個時間範圍切成 60 分鐘一格
↓
逐格找出有哪些 Range 覆蓋
↓
用 supporterIds 去重
↓
得到實際支持人數 count
↓
相鄰格子嘗試合併
↓
時間相鄰?
count 相同?
supporterIds 相同?
↓
三個都成立才合併
↓
最後依 count 由高到低排序

所以它不是單純:

「數一數哪個時間最多票。」

而是先把連續時間拆成統一的小單位,再保留每一格背後真正的支持者。

最後再把切碎的小格整理回:

建立者真正能拿來選活動時間的候選區間。


用 Test 確認:支持者不同就不能合併

BuJo 也替這個合併邊界留下了 Test。

it("票數相同但支持者不同不會合併", () => {
  const windowStart =
    new Date("2026-08-01T09:00:00Z");

  const windowEnd =
    new Date("2026-08-01T11:00:00Z");

  const ranges = [
    {
      start: new Date("2026-08-01T09:00:00Z"),
      end: new Date("2026-08-01T10:00:00Z"),
      user_id: "alice",
    },
    {
      start: new Date("2026-08-01T10:00:00Z"),
      end: new Date("2026-08-01T11:00:00Z"),
      user_id: "bob",
    },
  ];

  const result = computeRangeRanking(
    ranges,
    windowStart,
    windowEnd,
    2,
    participantsById,
  );

  expect(result).toEqual([
    expect.objectContaining({
      slot_start:
        new Date("2026-08-01T09:00:00Z"),
      slot_end:
        new Date("2026-08-01T10:00:00Z"),
      count: 1,
    }),
    expect.objectContaining({
      slot_start:
        new Date("2026-08-01T10:00:00Z"),
      slot_end:
        new Date("2026-08-01T11:00:00Z"),
      count: 1,
    }),
  ]);
});

這個 Test 驗證的就是:

兩格時間相鄰
+
count 都是 1
+
supporterIds 不同
↓
不能合併

也就是把「切碎之後要重新合併,但不能合錯人」這個邊界直接留成一條 Test 保護起來。


算對了,還要讓人看得懂

一開始我真的以為,這個功能就是把大家有空的時間丟進去,再算出重疊最多的區間而已。

結果真的做下去才發現,事情沒有這麼單純。

假設活動的可選範圍就是整整一天,就算系統把它切成一格一格來算,也總不能最後真的把 24 個選項全部丟給建立者看(笑)。

所以除了「算得對」,後面還要再想:

怎麼把這些結果整理成人真的看得懂、也方便做決定的樣子。

這也是我這次很有感的地方。

原本只是想幫大家把時間配在一起,最後才發現,真正難的其實不只演算法,還有怎麼把機器算出來的東西,變成使用者真的用得上的結果。

候選時段終於算完了,核心開發這一關也差不多走到尾聲啦~

明天開始,我們要把視線從「Code 怎麼算」移到另一個問題:

Code 寫好了,要怎麼穩定地送到真正運行的環境?

下一篇,就來進入 Deployment(部署)和 CI/CD 的世界。


上一篇
Day25|悲觀一點反而比較安全?從悲觀鎖看懂最後一個名額怎麼守
下一篇
Day27|網站終於有自己的連結了,這樣就算部署完成了嗎?從 Deployment 看懂 Environment 與 CI/CD
系列文
夢幻甜品師闖工程世界:Vibe Coding vs 專業開發的 0→1 冒險攻略30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言